refactor(remote): OETC/SSH as standalone transports (closes #683)#697
refactor(remote): OETC/SSH as standalone transports (closes #683)#697FBumann wants to merge 25 commits into
Conversation
|
@FBumann I think the concept makes sense. I would like to make sure that it is flexible enough for interfaces like gurobi instant cloud. could you take that into consideration? |
|
@FabianHofmann Yes, I already did think about it. But i can sketch it out a bit more, maybe even create a Remote parent class... |
It fits well for async instant cloud. For the sync instan cloud it doesnt fit as cleanly. But I think it can be wired in without adjusting public api. Probably just refactor some internal helpers for collection |
I guess async is the most important one |
I thin the regular gurobi cloud can be used by just using the regular gurobi solver anyway...? It fully dispatches to gurobipy, and gets a gurobipy solution back, right? SO no extra class needed... |
b221cc3 to
80e8be1
Compare
|
@FabianHofmann SO the new architecture should cover all cases. THe only issue i see is thst "remote" isnt a precise name. "worker" or "offload" might be better. But thats not as important for now i think |
Closes #683. The issue framed OETC as a `Solver` subclass to fold the `remote=` branch in `Model.solve` into the unified Solver pipeline. Trying that, the fit was wrong: remote handlers aren't solvers — they ship a netcdf elsewhere and let someone else solve. Forcing them through `Solver` required workarounds (a non-colliding `inner_solver` field name, property-vs-field collisions on `solver_name`, `SolverName` enum entries for things that aren't algorithms). Going standalone instead: - `linopy.remote.Oetc(settings, solver_name, options)` — standalone class with `upload(model)` / `submit()` / `collect(model)` / `solve(model)` lifecycle. The submit/collect split is in the right shape for future async work (a `blocking=False` solve, Gurobi-batch, etc.) without baking the seam into the Solver hierarchy. - `linopy.remote.SSH(settings, solver_name, options)` — synchronous ship-and-run handler. - Both produce a label-indexed `Result` via the shared `_scatter_solution_from_solved_model` helper in `linopy/remote/_common.py`. - Both validate the inner solver locally via `_validate_inner_solver` (unknown name raises; known-but-incapable raises before the round-trip). Settings dataclasses now pure transport. `OetcSettings.solver` and `OetcSettings.solver_options` are removed — those config axes live on the outer `Model.solve` call now, mirroring the local-solve API. New `SshSettings` follows the same shape. `Model.solve` changes: - `remote=<Settings>` → standalone-handler dispatch via the new `_solve_with_remote_settings` method. - `remote=OetcHandler/RemoteHandler` → legacy shim, emits `DeprecationWarning`, builds equivalent settings, routes to the same new pipeline. - New `model.remote` slot — set to the `Oetc`/`SSH` instance after a remote solve, lets callers introspect `model.remote._job_uuid` etc. `model.solver` is None during remote solves. The reformulation lifecycle (from #690) wraps the remote dispatch via `sos_reformulation_context` + `suppress_serialization_warning`, the same context managers the local-solve path uses. The `to_netcdf` UserWarning is suppressed for the handler's internal serialization. `OetcHandler.solve_on_oetc` emits a `DeprecationWarning` when called directly, pointing at the new API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
80e8be1 to
daadcae
Compare
|
@FabianHofmann I argue that the final form of API would be removing
This reflects the 2-level state of regular solvers: Pass name + options in Do you agree? |
…eprecate legacy handlers - `OetcHandler.__init__` / `RemoteHandler.__post_init__` emit `DeprecationWarning` pointing at `Oetc` / `SSH` and `Model.solve(remote=...)`. An `_internal=True` kwarg suppresses the warning when the new classes construct the handler themselves. - `OetcHandler.solve_on_oetc` delegates to `Oetc.solve` so the upload→submit→poll→download orchestration lives in one place. Legacy `Model` return shape preserved by reading `oetc._solved_model` after `collect`. - `Oetc.upload` / `SSH.solve` no-op handler construction when one is already attached, so the deprecated handler can be reused as the underlying transport without re-running auth. - Validation moved into `Oetc.solve` (was in `upload`) so the legacy handler path is unchanged for users. Two `TestSolveOnOetc` tests grow a few mock attrs (`_xCounter=0`, empty `.items()`, `termination_condition`) so the bare `Mock()` model flows through `Oetc.collect`'s scatter step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents the new `Oetc` / `SSH` standalone classes, the `Model.solve(remote=<Settings>)` entry point, and the deprecation of `OetcHandler` / `RemoteHandler`. Migration examples show both the recommended `Model.solve(remote=...)` path and the direct `Oetc.solve(m)` + `assign_result` path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`SshSettings.setup_commands` is a list of shell commands run on the remote interactive session before the inner solver is invoked — e.g. `setup_commands=["conda activate linopy-env"]`. Replaces the old pattern of holding a `RemoteHandler` instance and manually calling `.execute(...)`. The `examples/solve-on-remote.ipynb` notebook is rewritten to: - use `Model.solve(remote=SshSettings(...))` as the primary path, - demonstrate `setup_commands` for env activation, - show `SSH(settings, solver_name, options).solve(m)` as the advanced "drive the transport directly" path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Drops `OetcHandler` cells (deprecated) — primary path is now
`Model.solve("gurobi", remote=OetcSettings(...), **opts)`.
- Removes the settings-level `solver=` / `solver_options=` cell;
inner solver name and options live at the call site, matching the
local-solve shape.
- Replaces the retry/error-handling cell with an "Advanced" section
that walks through `Oetc.upload` / `Oetc.submit` / `Oetc.collect`
— the async-friendly seam that motivates the standalone class.
- Trims to essentials.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The rewritten notebooks dropped the notebook-level
`"nbsphinx": {"execute": "never"}` metadata, which both prior
versions had. Without it, the docs build tries to execute the cells
and fails on `os.environ["OETC_EMAIL"]` / a live SSH connect.
Restore the original metadata so the docs build returns to rendering
the notebooks as static content.
OetcCredentials was a 2-field wrapper (email, password) that added an extra construction layer with no functional payoff. Inline the two fields onto OetcSettings so the construction shape matches SshSettings (which takes username/password directly). OetcCredentials stays importable and emits a DeprecationWarning on construction; OetcSettings(credentials=...) is still accepted and copies the values through. To be removed in a future release. Note: the positional argument order on OetcSettings shifts because credentials is no longer the first required field. Existing keyword-arg callers (the typical case) are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… guide The two notebooks duplicated their model-creation cells and "Advanced: drive the transport directly" sections, while users picking a remote transport read one or the other — not both. Merge into a single `remote-machines.ipynb` with parallel SSH / OETC sections and a shared advanced section, plus a brief "which to pick?" table. Rename keeps the file out of the "solve-on-*" namespace (the docs section is already "Solving"); `remote-machines` describes what the page is about, not what you do with it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Switch the manual OetcSettings example from os.environ[...] to literal placeholder strings. Mixing os.environ access with the manual-construction example was confusing — environment loading is what from_env() is for. - Drop the SSH-vs-OETC comparison table at the end. The information is obvious from each section's 'What you need' bullets.
The `remote` extra installed only `paramiko` — i.e., the SSH transport deps. With OETC as a parallel transport (own `linopy[oetc]` extra), the `remote` name was misleading and asymmetric. Rename to `ssh` to match what it installs. Drop the old `remote` extra (rather than alias it) because: - It only shipped in v0.7.0 (recent, narrow adoption). - Pip extras have no runtime deprecation mechanism, so the alias would just defer an inevitable break. - Aliasing leaves a redundant extra in the API surface. Documented under "Breaking Changes" in the release notes; the merged remote-machines notebook is updated to use `linopy[ssh]`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `test/remote/test_remotes.py` covering the new public surface
that `test_oetc.py` and `test_ssh.py` don't (those still focus
on the deprecated Handler classes):
- `Oetc.solve` happy path with a mocked `OetcHandler`.
- `Oetc.upload` / `submit` / `collect` as separable steps.
- `SSH.solve` happy path; `SshSettings.setup_commands` runs on the
remote shell on first handler construction.
- Inner-solver validation (unknown name raises in both transports).
- `Model.solve(remote=OetcSettings(...))` / `Model.solve(remote=SshSettings(...))`
end-to-end with `Oetc.solve` / `SSH.solve` monkeypatched.
- Deprecation warnings on `OetcHandler`, `RemoteHandler`,
`OetcCredentials`, and `Model.solve(remote=<Handler>)`.
- `_internal=True` suppresses the handler deprecation warnings on
the construction path used internally by `Oetc` / `SSH`.
Also updates `test-notebooks` skip-list for the renamed merged
notebook (`remote-machines.ipynb` replaces `solve-on-{remote,oetc}.ipynb`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The API page only documented the deprecated `RemoteHandler`. Add the new public classes (`SSH`, `Oetc`, `SshSettings`, `OetcSettings`) and the remaining deprecated entries (`OetcHandler`, `OetcCredentials`) so autosummary generates a stub for each. The new entries link to the merged `remote-machines` user guide. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@FabianHofmann Ready for a review |
Resolved conflict in examples/solve-on-oetc.ipynb: the PR intentionally deletes this notebook (replaced by examples/remote-machines.ipynb), while master only applied cosmetic nbstripout/unicode normalization. Kept the deletion.
…ng text
A user passing m.solve("gurobi", remote=...) only ever supplies one
solver, and remotes are transports rather than solvers, so "inner" has
no "outer" to contrast with. Drop it from docstrings, validation error
messages, and release notes. Internal symbols (inner_solver param,
_validate_inner_solver) keep the name — there it disambiguates the
shipped solver string from the transport object.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@FabianHofmann ready. Do you wait for feedback of oetc users? |
The :class: target pointed at linopy.solvers.SSH, but SSH is exported from linopy.remote and is a transport, not a solver. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The public API is already "remote" (remote= kwarg, model.remote attribute, linopy.remote module), so prose naming the same concept "transport" gave readers two words for one thing. "Transport-only" as a qualifier stays — it has no plain "remote" equivalent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@FabianHofmann Ill look into the async stuff for oetc a bit, but cant test without oetc credentials. SO we will see how far i can go |
|
As im looking into async usage with oetc, the design decision of having Ssh and Oetc return So i think changing this to make |
|
Design change since the description: The "Remote returns Key rationale:
To be folded into the PR description. |
Two changes to the standalone remote API: Return Model, not Result. `Oetc.solve` / `Oetc.collect` / `SSH.solve` return the solved `Model` directly. The worker already produces an inflated solved model, so the `Result` shape forced a redundant Model -> flat -> Model round-trip. `Model.solve(remote=...)` now folds the solved model into the caller's model via the new `Model._assign_from_solved_model` — the same in-place fold v0.7.0 used, so `Model.solve(remote=...)` behaviour is unchanged. The `_scatter_solution_from_solved_model` helper is removed; local solves keep the `Result` / `assign_result` path. Oetc is a connection, not a job. `Oetc(settings)` authenticates once and drives any number of jobs: `submit(model, solver_name, **options)` returns a job uuid, `collect(uuid)` returns the solved model, `status(uuid)` is a non-blocking status check, `solve(...)` is the one-shot. Because a uuid is the only job handle, multi-model, cross-process and non-blocking orchestration all work without linopy owning an event loop. `SSH` takes the matching `SSH(settings)` / `solve(model, solver_name, **options)` shape. `OetcSettings.solver` / `solver_options` are deprecated; pass the solver to `Model.solve` / `Oetc.submit` instead. They are still honoured during deprecation (as a `Model.solve` fallback and by the deprecated `OetcHandler`) and will be removed with it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rename "transport config" to "connection config", describe Oetc as a token-based session rather than a connection, frame SSH vs OETC as self-hosted vs managed, and fix stale API signatures in the OetcHandler and RemoteHandler deprecation messages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
for more information, see https://pre-commit.ci
The OETC auth token has a limited lifetime, but nothing ever refreshed it. Two cases failed: - A reused `Oetc` connection — submit, then collect after the token lifetime — sent stale-token requests that 401'd. - A single `collect()` whose blocking poll outlived the token, which is the normal case for the large, long-running solves OETC exists for. `Oetc._session()` now rebuilds the handler when `jwt.is_expired`, and `OetcHandler.wait_and_get_job_data()` re-signs-in at the top of each poll iteration. GCP transfers are unaffected — they use the service-account key, not the user JWT. Test mocks now set `jwt.is_expired = False` (a freshly built handler has a live token) so the new checks do not misfire. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@FabianHofmann I put some more work in. Its now truly ready for async polling, fully relying on a job uuid. |
Closes #683 — with a different design than the issue proposed.
Why standalone, not
SolversubclassThe issue framed OETC as a
Solversubclass soModel.solvecould fold theremote=branch into the unified Solver pipeline. The fit was wrong:Solverrequired workarounds: a non-colliding field for the worker's solver, property-vs-field collisions onsolver_name,SolverNameenum entries for things that aren't algorithms.Solver's feature-flag plumbing is about local solver capabilities; for remotes we want to validate the worker's solver flags.So
OetcandSSHare standalone classes inlinopy/remote/, parallel toSolver, not subclasses of it.What's new
solver_nameand**solver_optionsare the same axes as for a local solve;remote=selects where to run.Model.solve(remote=...)folds the solution into your model in place and returns(status, termination_condition)— behaviour-identical to v0.7.0.model.remoteattribute mirrorsmodel.solverfor post-solve introspection and connection reuse.SshSettings.setup_commands: list[str]runs shell commands on the remote before the solver — replaces the old "build aRemoteHandlerand call.execute(...)" pattern.remote→ssh; OETC has its ownlinopy[oetc]extra.linopy[remote]is dropped — see Breaking Changes in the release notes.Remote calls return a
ModelOetc.solve/Oetc.collect/SSH.solvereturn the solvedModel, not aResult. The worker (read_netcdf → m.solve → to_netcdf) natively produces a fully inflated solved model — forcing it into the flatResultshape meant a redundantModel → flat arrays → Modelround-trip.Model.solve(remote=...)folds the solved model into the caller's model via the newModel._assign_from_solved_model— the v0.7.0 in-place fold logic, extracted into a method. SoModel.solve(remote=...)is behaviour-identical to v0.7.0: it mutates the original model and returns the status tuple.Oetc/SSHobjects hand back a new solvedModel; the model you pass in is not modified._scatter_solution_from_solved_modelis deleted. Local solves are unchanged — solvers natively emit flat label-indexed arrays, so they keep theResult/Model.assign_resultpath.Each path is now direct — no flatten/reinflate hop.
Oetcis a connection, not a jobOetc(settings)is a reusable connection: authenticate once, drive any number of jobs.A job is identified solely by its uuid string, which makes the lifecycle genuinely async-friendly:
Oetc(OetcSettings.from_env())in another process (or hours later) collects it.collect()needs nothing but the uuid and the settings.status(uuid)for a user-driven poll loop; the sync primitives compose into any async runtime viaasyncio.to_thread/ a thread pool.SSH(settings)is the synchronous analogue — a reusable connection withsolve(model, solver_name, **options); no submit/collect seam (the SSH session is held open and the solve is synchronous).Solver config is per call, not per object:
solver_name/**optionsare arguments tosubmit/solve/Model.solve, mirroring the local-solve API.Token re-authentication
The OETC auth token has a limited lifetime;
Oetcnow re-authenticates transparently:Oetc._session()rebuilds the handler when the token has expired — covers a connection reused across the token lifetime (submit, then collect later).OetcHandler.wait_and_get_job_data()re-signs-in at the top of each poll iteration — so a singlecollect()whose blocking poll outlives the token (the normal case for large solves) keeps working. GCP transfers are unaffected; they use the service-account key, not the user JWT.SSH is narrower than
RemoteHandlerSSHcovers solving a model on a remote machine — not being a general remote-shell wrapper.RemoteHandlerSSHsolve_on_remote(m)solve(m, solver_name)handler.execute("conda activate …")SshSettings.setup_commands=[…]SSH(settings)reused)handler.execute(any cmd)RemoteHandler(deprecated) or paramiko directlyDeprecations
OetcHandler(...)/RemoteHandler(...)construction emitsDeprecationWarning. Theirsolve_on_oetc/solve_on_remotereturn contracts are unchanged;solve_on_oetcdelegates toOetcinternally.Model.solve(remote=<Handler>)is deprecated — pass the settings dataclass instead.OetcCredentialsis deprecated — passemail=/password=directly toOetcSettings.OetcSettings.solver/OetcSettings.solver_optionsare deprecated — pass the solver toModel.solve(solver_name, ...)orOetc.submit(model, solver_name, ...). During deprecation they are still honoured (as a fallback whenModel.solve(remote=OetcSettings(...))is called without asolver_name, and by the deprecatedOetcHandler), and will be removed withOetcHandler.Hand testing needed
The entire
test/remote/suite is mocked — no real network. Everything that touches a live OETC account or SSH server needs manual verification before merge:OETC (account +
linopy[oetc]):m.solve("gurobi", remote=OetcSettings(...))end-to-end — auth, GCP upload, job submit, poll, download, solution folded ontom.Oetc(settings)driven manually —submit/status/collect;solve()one-shot.collect()it from a fresh process.OetcSettings.from_env().OetcHandler(...).solve_on_oetc(...),Model.solve(remote=<OetcHandler>),Model.solve(remote=OetcSettings(..., solver="gurobi")).SSH (remote server +
linopy[ssh]):m.solve("gurobi", remote=SshSettings(hostname=..., setup_commands=[...]))end-to-end.SSH(settings)reused across several.solve()calls — one connection,setup_commandsrun once.RemoteHandler(...).solve_on_remote(...),Model.solve(remote=<RemoteHandler>).Docs:
examples/remote-machines.ipynbagainst real services (it isnbsphinx execute=never, so never CI-checked).Follow-ups (not in this PR)
OetcHandler/RemoteHandler. Their private transport methods still live on the Handler classes;Oetc/SSHreach intoself._handler._...to use them. A follow-up will migrate that code intoOetc/SSHand port the OETC handler-internals tests.OetcCredentials,OetcSettings.solver/solver_options— after one release cycle of deprecation warnings.is_expiredcheck); a reactive re-auth on a401would also cover a server expiring the token earlier than advertised.Test plan
pytest test/remote/ test/test_sos_reformulation.py test/test_oetc_settings.py— 193 pass (all mocked).ruff check,ruff format,mypy— clean.pytest --ignore=test/remote) — re-run before merge.🤖 Generated with Claude Code